# 作用域 🔥

# 编译原理

JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言,它不是提前编译的,编译结果也不能在分布式系统中进行移植。

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

# 分词/词法分析(Tokenizing/Lexing)

这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成 为下面这些词法单元:vara=2;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。

分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的, 主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。

# 解析/语法分析(Parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。 var a = 2;的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression 的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子节点。

# 代码生成

将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。 抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中

# JavaScript 引擎

比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在 语法分析 和 代码生成 阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。

首先,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如 JIT,可以延迟编译甚至实施重编译)来保证性能最佳。

简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此, JavaScript 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

# 如何理解作用域 🔥

# 作用域是什么?🔥

储存和访问或修改变量的值的能力使程序有了状态。那么这些变成存储在哪?程序在使用时如何寻找到它?

上述问题说明需要一套设计良好的规则来存储变量,并且之后可以根据名称查到这些变量。 这套规则被称为作用域

# 分析角度

理解作用域需要以如下三个角度来审视问题:

  • 引擎

    从头到尾负责整个 JavaScript 程序的编译及执行过程。

  • 编译器

    引擎的好朋友之一,负责语法分析及代码生成等脏活累活(详见前一节的内容)。

  • 作用域

    引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

# 分析过程

var a = 2;

上述程序对于引擎来说,他会认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理

编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。

可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内存,将其命名为 a,然后将值 2 保存进这个变量。”然而,这并不完全正确。事实上编译器会进行如下处理。

  1. 遇到 var a编译器询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,即 LHS,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。
  3. 如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异常!

总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值

简单说:

  1. 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
  2. 接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。

# 编译器的查找—LHS & RHS 🔥

在上述例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS。当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。并不一定意味着就是“= 赋值操作符的左侧或右侧”

  • LHS 查询则是试图找到变量的容器本身,从而可以对其赋值

  • RHS 查询与简单地查找某个变量的值别无二致,即找值

    从这个角度说,RHS 并不是真正意义上的“赋值操作的右侧”,更准确地说是“非左侧!!!”。可将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。可以将 RHS 理解成 retrieve his source value(取到它的源值)

示例如下:

console.log(a);

其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。相应地,需要查找并取得 a 的值,这样才能将值传递给 console.log(..)。

a = 2;

这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 = 2 这个赋值操作找到一个目标。

function foo(a) { 
  console.log(a); // 2
}
foo(2);
  • 最后一行 foo(..) 函数的调用需要对 foo 进行 RHS 引用,并且 (..) 意味着 foo 的值需要被执行,因此它最好真的是一个函数类型的值!
  • 代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给 foo(..) 函数时,2 会被分配给参数 a。为了给参数 a(隐式地)分配值,需要进行一次 LHS 查询。
  • 这里还有对 a 进行的 RHS 引用,并且将得到的值传给了 console.log(..)。console.log(..) 本身也需要一个引用才能执行,因此会对 console 对象进行 RHS 查询,并且检查得到的值中是否有一个叫作 log 的方法。
  • 最后,在概念上可以理解为在 LHS 和 RHS 之间通过对值 2 进行交互来将其传递进 log(..) (通过变量 a 的 RHS 查询)。假设在 log(..) 函数的原生实现中它可以接受参数,在将 2 赋值给其中第一个(也许叫作 arg1)参数之前,这个参数需要进行 LHS 引用查询。

你可能会倾向于将函数声明 function foo(a) {... 概念化为普通的变量声明和赋值,比如 var foo、foo = function(a) {...。如果这样理解的话,这个函数声明将需要进行 LHS 查询。然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值“分配给”foo。因此,将函数声明理解成前面讨论的 LHS 查询和赋值的形式并不合适。

# 引擎和作用域对话

function foo(a) { 
  console.log(a); // 2
}
foo(2);

让我们把上面这段代码的处理过程想象成一段对话,这段对话可能是下面这样的。

  • 引擎:我说作用域,我需要为 foo 进行 RHS 引用。你见过它吗?

  • 作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。

  • 引擎:哥们太够意思了!好吧,我来执行一下 foo。

  • 引擎:作用域,还有个事儿。我需要为 a 进行 LHS 引用,这个你见过吗?

  • 作用域:这个也见过,编译器最近把它声名为 foo 的一个形式参数了,拿去吧。

  • 引擎:大恩不言谢,你总是这么棒。现在我要把 2 赋值给 a。

  • 引擎:哥们,不好意思又来打扰你。我要为 console 进行 RHS 引用,你见过它吗?

  • 作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console 是个内置对象。 给你。

  • 引擎:么么哒。我得看看这里面是不是有 log(..)。太好了,找到了,是一个函数。

  • 引擎:哥们,能帮我再找一下对 a 的 RHS 引用吗?虽然我记得它,但想再确认一次。

  • 作用域:放心吧,这个变量没有变动过,拿走,不谢。

  • 引擎:真棒。我来把 a 的值,也就是 2,传递进 log(..)。

  • ......

# 作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。

function foo(a) { 
  console.log( a + b );
}
var b = 2; 
foo( 2 ); // 4

对 b 进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域(在这个例子中就 是全局作用域)中完成。

# 异常 🔥

为什么区分 LHS 和 RHS 是一件重要的事情?

因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。

function foo(a) { 
  console.log(a + b); 
  b = a;
}
foo( 2 );

ReferenceError

  • 第一次对 b 进行 RHS 查询时是无法找到该变量的。这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。

  • 相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在严格模式”下。

    ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在 严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询 失败时类似的 ReferenceError 异常。

TypeError

  • 接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作, 比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

# 词法作用域 🔥

作用域共有两种主要的工作模型。

  • 词法作用域:最为普遍的,被大多数编程语言所采用,我们会对这种作用域进行深入讨论。
  • 动态作用域:仍有一些编程语言在使用(比如 Bash 脚本、Perl 中的一些模式等)。

# 词法阶段—分词/词法分析

词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义,即词法分析,否则是分词。

词法作用域就是定义在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的,还会有欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域)。

# 作用域查找 🔥

function foo(a) { 
  var b = a * 2;
	function bar(c) { 
    console.log( a, b, c );
	}
bar( b * 3 ); 
}   
foo( 2 ); // 2, 4, 12

在上述例子中有三个逐级嵌套的作用域。从最外层开始:

  • 包含着整个全局作用域,其中只有一个标识符:foo。

  • 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b。

  • 包含着 bar 所创建的作用域,其中只有一个标识符:c。

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应, 作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此 可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引 用来对其进行访问。window.a。通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz, 词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接 管对 bar 和 baz 属性的访问。

# 欺骗词法作用域

如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修改”(也可以说欺骗)词法作用域呢?JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能下降

# eval & setTimeout ...

JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时(词法期)存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。

在执行 eval(..) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。

function foo(str, a) { 
  eval( str ); // 欺骗! 
  console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

根据原理可知,这段代码实际上在 foo(..) 内部创建了一个变量 b,并遮蔽了外部(全局)作用域中的同名变量。

在这个例子中,为了展示的方便和简洁,我们传递进去的“代码”字符串是固定不变的。而在实际情况中,可以非常容易地根据程序逻辑动态地将字符拼接在一起之后再传递进去。eval(..)通常被用来执行动态创建的代码,因为像例子中这样动态地执行一段固定字符所组成的代码,并没有比直接将代码写在那里更有好处。

默认情况下,如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对 eval(..) 所处的词法作用域进行修改。技术上,通过一些技巧(已经超出我们的讨论范围)可以间接调用 eval(..) 来使其运行在全局作用域中,并对全局作用域进行修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。

但是在严格模式的程序中,eval(..)运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) { 
  "use strict";
	eval( str );
	console.log( a ); // ReferenceError: a is not defined 
}
foo( "var a = 2" );

JavaScript 中还有其他一些功能效果和eval(..)很相似。setTimeout(..)setInterval(..)第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用它们!

new Function(..) 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比 eval(..) 略微安全一些,但也要尽量避免使用。

在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。

# with

JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是 with 关键字。

with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = { 
  a: 1,
  b: 2,
  c: 3 
};
// 单调乏味的重复 "obj" 
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式 
with (obj) {
  a = 3;
  b = 4;
  c = 5;
}

但实际上这不仅仅是为了方便地访问对象属性。考虑如下代码:

function foo(obj) {
  with (obj) {
    a = 2;// 此处是为了给obj对象的a属性赋值,执行LHS
    var c = 666;// 此处变量不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中
  }
  console.log(c)
}
var o1 = {
  a: 3
};
var o2 = {
  b: 3
};

foo(o1);// 666
console.log(o1.a); // 2

foo(o2);// 666
console.log(o2.a); // undefined
console.log(a); // 2——不好,a 被泄漏到全局作用域上了!
console.log(c);// ReferenceError, c is not defined

这个例子中创建了 o1 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。foo(..) 函数接受一个obj参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。 在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个 LHS 引用,并将 2 赋值给它。

当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console. log(o1.a) 中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性, o2.a 保持 undefined。

但是可以注意到一个奇怪的副作用,实际上 a = 2 赋值操作创建了一个全局的变量 a。这 是怎么回事?

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中不添加var时且 LHS 查不到会创建全局变量

可以这样理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含 有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符, 因此进行了正常的 LHS 标识符查找。

o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。with 这种将对象及其属性放进一个作用域并同时分配标识符的行为很让人费解。

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

另外一个不推荐使用 eval(..) 和 with 的原因是会被严格模式所影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。

# 性能

如果它们能实现更复杂的功能,并且代码更具有扩展性会更好。其实不然。

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量函数定义位置,才能在执行过程中快速找到标识符。但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。最悲观的情况是如果出现了 eval(..) 或 with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。如果代码中大量使用 eval(..) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。

# 函数作用域 🔥

# 函数作用域

考虑下面的代码:

function foo(a) { 
  var b = 2;
	// 一些代码
	function bar() { 
    // ...
	}
  // 更多的代码 
  var c = 3;
}
bar(); // 失败 ReferenceError
console.log( a, b, c ); // 三个全都失败 ReferenceError

由于标识符 a、b、c 和 bar 都附属于 foo(..) 的作用域,因此无法从 foo(..) 的外部直接对它们进行访问。但在 foo(..) 的内部都是可以被访问的,同样在 bar(..) 内部也可以被访问(假设 bar(..) 内部没有同名的标识符声明)

  • 体现了封装,隐藏内部实现的设计原则
  • 可以规避同名标识符冲突(多个第三方库)。这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。也可以使用模块管理避免同名标识符冲突
function foo() { 
  function bar(a) {
    i = 3; // 修改for循环所属作用域中的i,因为for没有作用域,i被声明在foo中了
    console.log( a + i );
  }
  
  for (var i=0; i<10; i++) {
    bar( i * 2 ); // 糟糕,无限循环了!
  } 
}
foo();

# 立即执行函数表达式

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题

  • 必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。
  • 必须显式地通过函数名(foo())调用这个函数才能运行其 中的代码。

# 用处1

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行, 这将会更加理想。如下:

var a = 2;

(function foo(a) { // <-- 添加这一行,也可以省略foo用做匿名函数
  var a = 3;
  console.log(a); // 3 
})(a); // <-- 以及这一行,可以转递参数

// 也可以这样写
//(function foo(b) { // <-- 添加这一行,也可以省略foo用做匿名函数
//  var a = 3;
//  console.log(a); // 3 
//}(b)); // <-- 以及这一行,可以转递参数

console.log(a); // 2

由于函数被包含在一对 ( ) 括号内部,函数会被当作函数表达式而不是一个标准的函数声明来处理,通过在末尾加上另外一个 ( ) 可以立即执行这个函数。即 IIFE(Immediately Invoked Function Expression) 立即执行函数表达式

foo 被绑定在函数表达式自身的函数中而不是所在作用域中。意味着foo只能在..所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。也可以省略 foo 写作匿名函数表达式。

注意

区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明否则就是一个函数表达式

# 用处2

这个模式的另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(虽然不常见)。

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! 
(function IIFE(undefined) {
  var a;
  if (a === undefined) {
    console.log("Undefined is safe here!");
  }
})();// Undefined is safe here!

# 用处3

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD(Universal Module Definition)项目中被广泛使用。尽管这种模式略显冗长,但有些人认为它更易理解。

var a = 2;
(function IIFE( def ) { 
  def( window );
})(function def( global ) {
  var a = 3;
  console.log( a ); // 3 
  console.log( global.a ); // 2
});

函数表达式 def 定义在片段的第二部分,然后当作参数(这个参数也叫作 def)被传递进 IIFE 函数定义的第一部分中。最后,参数 def(也就是传递进去的函数)被调用,并将 window 传入当作 global 参数的值。

# 块作用域 🔥

除 JavaScript 外的很多编程语言都支持块作用域

# var—没有块作用域

当无论在哪里使用 var 声明变量时,它们最终都会属于外部作用域(函数或全局。for 没有作用域,此处绑定到全局作用域)

for (var i=0; i<10; i++) { 
  console.log( i );
}

我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。

如下例子

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button>按钮1</button>
    <button>按钮2</button>
    <button>按钮3</button>
    <button>按钮4</button>
    <button>按钮5</button>
    <script>
      var btns = document.getElementsByTagName("button");

      //   var
      //   for (var i = 0; i < btns.length; i++) {
      //     btns[i].addEventListener("click", function () {
      //       console.log(`按了第${i}个按钮`); // 按了第5个按钮。无论怎么都是输出这个
      //     });
      //   }

      //   立即执行函数
      //   for (var i = 0; i < btns.length; i++) {
      //     (function (i) {
      //       btns[i].addEventListener("click", function () {
      //         console.log(`按了第${i}个按钮`); // 正常
      //       });
      //     })(i);
      //   }

      // let
      for (let i = 0; i < btns.length; i++) {
        btns[i].addEventListener("click", function () {
          console.log(`按了第${i}个按钮`); // 正常
        });
      }
    </script>
  </body>
</html>

因为函数中的打印语句引用的 i 是一个外部变量不是传入的参数,不受函数作用域控制,绑定事件的循环中 i 一直在改变,最终输出时,获取到的变量都是 5!全局变量污染

当使用立即执行函数后,由于i被当作参数传入立即执行函数,此时再去修改i都不会影响函数中的值。

当使用let后,{}中的i也是属于该作用域的,不受外部i的改变而变化

# with

with 关键字。它不仅是一个难于理解的结构,同时也是块作用域的一个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效

# try/catch

JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
  undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
  console.log( err ); // 能够正常执行! 
}
console.log( err ); // ReferenceError: err not found

WARNING

尽管这个行为已经被标准化,并且被大部分的标准 JavaScript 环境(除了老版本的 IE 浏览器)所支持,但是当同一个作用域中的两个或多个 catch 分句 用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告。 实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部, 但是静态检查工具还是会很烦人地发出警告。

为了避免这个不必要的警告,很多开发者会将 catch 的参数命名为 err1、 err2 等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。

这是向 ES6 中的所有(大部分)功能迁移的首选方式:在从 ES6 之前的环境向 ES6 过渡时,使用代码转换工具来对 ES6 代码进行处理,生成兼容 ES5 的代码。

{
  let a = 2;
  console.log( a ); // 2 
}
console.log( a ); // ReferenceError

这段代码在 ES6 环境中可以正常工作。但是在 ES6 之前的环境中如何才能实现这个效果?可以使用 catch。

try{
  throw 2;
}catch(a){ 
  console.log( a ); // 2
}
console.log( a ); // ReferenceError

# let

ES6引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。let 关键字可以将变量绑定到所在的任意作用域中(通常是 {} 内部)。换句话说,let 为其声明的变量隐式地指定了所在的块作用域。

var foo = true;
if (foo) {
  let bar = foo * 2;
  bar = something( bar ); 
  console.log( bar );
}
console.log( bar ); // ReferenceError

# let循环

for (let i=0; i<10; i++) { 
  console.log( i );
}
console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

下面通过另一种方式来说明每次迭代时进行重新绑定的行为(用于闭包,不懂):

{
  let j;
  for (j=0; j<10; j++) {
    let i = j; // 每个迭代重新绑定!
    console.log( i ); }
}

# 垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。

function process(data) {
  // 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) { 
  console.log("button clicked");
}, /*capturingPhase=*/false );

click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执 行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成 了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:

function process(data) {
  // 在这里做点有趣的事情
}
// 在这个块中定义的内容可以销毁了! {
let someReallyBigData = { .. }; 
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){ 
  console.log("button clicked");
}, /*capturingPhase=*/false );

# const

ES6 还引入了 const,同样可以用来创建块作用域变量

var foo = true;
if (foo) {
  var a = 2;
  const b = 3; // 包含在 if 中的块作用域常量
  a = 3; // 正常!
  b = 4; // 错误! 
}
console.log( a ); // 3
console.log( b ); // ReferenceError!

其值是固定的 (常量)。之后任何试图修改值的操作都会引起错误

创建后必须进行初始化,否则报错

const a = 1;
const b;
b = 1;// Uncaught SyntaxError: Missing initializer in const declaration
const c;// Uncaught SyntaxError: Missing initializer in const declaration

常量的含义是指向的对象不能修改,但是可以改变对象内部的属性。数组也一样

const obj = {
    name: "conanan",
    age: 18,
    sex: "male",
};
console.log(obj);
obj.age = 25;
console.log(obj);

# 声明的提升 🔥

函数声明和变量声明都会被提升到各自作用域顶端,且函数会首先被提升,然后才是变量。但是函数表达式却不会被提升

看如下代码:

a = 2;
var a; 
console.log( a );// 2
console.log( a );// undefined
var a = 2;

词法作用域的核心内容:引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。所以变量和函数在内的所有声明都会在任何代码被执行前首先被处理

所以上述代码编译会进行如下处理:

var a; 
a = 2;
console.log( a );// 2
var a; 
console.log( a ); 
a = 2;// undefined

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码执行的顺序,会造成非常严重的破坏。

foo();
function foo() {
  console.log( a ); // undefined var a = 2;
}
function foo() { 
  var a;
  console.log( a ); // undefined
  a = 2; 
}
foo();

函数声明会被提升,但是函数表达式却不会被提升,如下:

foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() { 
  // ...
};
var foo;
foo(); // 不是 ReferenceError, 而是 TypeError!
foo = function bar() { 
  // ...
};

变量标识符 foo 被提升并分配给所在作用域(在这里是全局作用域),因此 foo() 不会导致 ReferenceError。但是 foo 此时并没有赋值(如果它是一个函数声明而不 是函数表达式,那么就会赋值)。foo() 由于对 undefined 值进行函数调用而导致非法操作, 因此抛出 TypeError 异常。

即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() { 
  // ...
};
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
  var bar = ...self... 
  // ...
}

函数会首先被提升,然后才是变量,特别注意有多个“重复”声明的代码中:

foo(); // 1
var foo;
function foo() { 
  console.log( 1 );
}
foo = function() { 
  console.log( 2 );
};
function foo() { 
  console.log( 1 );
}
foo(); // 1
foo = function() { 
  console.log( 2 );
};

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的,如下:

foo(); // 3
function foo() { 
  console.log( 1 );
}
var foo = function() { 
  console.log( 2 );
};
function foo() { 
  console.log( 3 );
}
function foo() { 
  console.log( 1 );
}
function foo() { 
  console.log( 3 );
}
foo(); // 3
foo = function() { 
  console.log( 2 );
};

由上述说明了在同一个作用域中进行重复定义是非常糟糕的,而且经常会导致各种奇怪的问题。

再看个例子

console.log(typeof fun === 'function') // true
var fun = 3
console.log(typeof fun === 'number') // true
function fun() {}
console.log(typeof fun === 'number') // true`
function fun() {}
var fun
console.log(typeof fun === 'function') // true
fun = 3
console.log(typeof fun === 'number') // true
console.log(typeof fun === 'number') // true

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制。但是需要注意这个行为并不可靠,在 JavaScript 未来的版本中有可能发生改变,因此应该 尽可能避免在块内部声明函数。如下代码已经执行不了了,TypeError: foo is not a function

foo(); // "b"
var a = true; 
if (a) {
  function foo() { 
    console.log("a"); 
  } 
}else {
  function foo() { 
    console.log("b"); 
  }
}

#